Skip to content

[WIP] Initial multiple queues support#865

Draft
mikooomich wants to merge 13 commits intoFoedusProgramme:betafrom
mikooomich:multiqueue
Draft

[WIP] Initial multiple queues support#865
mikooomich wants to merge 13 commits intoFoedusProgramme:betafrom
mikooomich:multiqueue

Conversation

@mikooomich
Copy link
Copy Markdown
Collaborator

@mikooomich mikooomich commented Mar 15, 2026

Adds "basic" support for multiple queues. Other needed features (ex. lastplayedmanger, queue menu) will be added later, or much later in terms of nice to haves (ex. multiselect, search, etc).

To test: Enable in Flags.kt, then enable settings toggle

Screenshot_20260425-154337_Gramophone Screenshot_20260425-154327_Gramophone Screenshot_20260425-154348_Gramophone Screenshot_20260425-154344_Gramophone

customExtras.putInt("index", index)
}, Bundle.EMPTY
).get().extras.run {
if (containsKey("allQueues")) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why bother with containsKey if you throw anyway, you can just let the !! do its job

/**
* Deletes a queue.
*
* When deleting the active queue,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

,

if (index == masterQueues.lastIndex) {
masterQueues.removeAt(index)
if (index <= 0) {
player.pauseAllPlayersAndStopSelf() // TODO: correct way to stop playback
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just removeMediaItems()

val endedWorkaroundPlayer
get() = mediaSession?.player as EndedWorkaroundPlayer?
private var controller: MediaBrowser? = null
val qb: QueueBoard = QueueBoard(this)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe should be inited in onCreate

}

// TODO: shuffle and repeat mode
fun MediaController.playQueue(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you are doing this at the wrong level, Android AUto for example will just not call your SERVICE_QB_ENQUEUE, it will keep doing setMediaItems(). Instead a player wrapper should give old queue to queueboard before executing setMediaItems()

/**
* Retrieve a song given a song ID. Returns null if no song is found
*/
fun findSong(mediaId: String): MediaItem? {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unused?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For future uses. Should i remove it for the time being?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

having smth for future use is ok if you know for what use, but atleast for me its not really clear what its useful for


val current = (instance?.currentMediaItemIndex ?: 0)
if (current > playlistAdapter.playlist.second.size) {
// hax to workaround ui loading itself 4 times
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

?

controller?.setMediaItems(albums.shuffled().flatMap { it.songList })
controller?.prepare()
controller?.play()
controller?.playQueue(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cf, this ui code shouldnt be changed. isOriginal is always true after setMediaItems(), it should only be considered changed after add/remove/move(/replace if id changed)


if (startIndex == 0) {
val playerItemCount = plr.mediaItemCount
// player.player.replaceMediaItems seems to stop playback so we
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

replaceMediaItems probably does but replaceMediaItem without s does not, update current song with repalceMediaItem too

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The currently playing media item can be at any index, and it needs to become the first index.. I'm not sure how replaceMediaItem would work better than what I current have.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mean in addition to what you have, as replaceMediaItem updates metadata without interruptting playback

return null
}

// I have no idea why this value gets reset to 0 by the end... but ig this works
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

?

Comment thread app/src/main/res/values/strings.xml Outdated
<string name="actions_query_shuffle_specific">shuffle $item_name</string>
<string name="please_allow_to_delete">Press allow to delete files</string>
<string name="choose_sd">Choose %s</string>
<string name="settings_mq_preview">Enable multiple queues preview</string>
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

try to avoid mentioning preview, instead use strings that can be reused once feature is stable, to avoid double work for translators

@mikooomich mikooomich force-pushed the multiqueue branch 2 times, most recently from 02e20d1 to 4304106 Compare April 8, 2026 14:46
* And so it begins: My attempt to implement one of OuterTune's most
  cursed features (code wise), but without programming crimes.
mq: wip queue loading ui

mg: Guard behind flag

mq: Queue expiry

* LocalDateTime methods shouldn't need sdk 26. Figure it out later
* Changed some nonsense with loading LastPlayedManager queue
* Fix some mq loading issues
* Hijjack setMediaItems() instead of explicit playQueue(),
* wip multiqueue shuffle (ui issues remain)
startPositionMs = startPositionMs
)
queueBoard.commitQueue(mq)
return Futures.immediateVoidFuture()
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why does this not call super?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

commitQueue calls setCurrQueue, which then calls realSetMediaItems, so calling handleSetMediaItems setmediatems just leads to a duplicate call

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought we already etablished that queueboard should only own inactive queues and the title of the active queue. that is, in setMediaItems it should copy the old queue into quereboard, not the new one. because if you want to do it this way then you also need to override at least handleAddMediaItems, handleMoveMediaItems, handleReplaceMediaItems, handleRemoveMediaItems, handleSeek which is stupid amount of effort, while if qb doesn't own current queue 1. nothing related to resumption etc needs to change 2. you avoid all of that spagetthi.

@mikooomich
Copy link
Copy Markdown
Collaborator Author

Hello. It has been... a while. I don't remember if I made all the requested changes, so if i missed anything plz let me know.

Known issues I will take care of... soon

  • queue title
  • wonky queue list close animation
  • play buttons from artists/albums etc page do not queue properly

Please help me figure out why the song recycler can scroll down but not up. Please please please please

Copy link
Copy Markdown
Member

@nift4 nift4 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry don't have time for nested scroll debugging for at least 2 weeks, all I can give you rn is this quick review pass

startPositionMs = startPositionMs
)
queueBoard.commitQueue(mq)
return Futures.immediateVoidFuture()
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought we already etablished that queueboard should only own inactive queues and the title of the active queue. that is, in setMediaItems it should copy the old queue into quereboard, not the new one. because if you want to do it this way then you also need to override at least handleAddMediaItems, handleMoveMediaItems, handleReplaceMediaItems, handleRemoveMediaItems, handleSeek which is stupid amount of effort, while if qb doesn't own current queue 1. nothing related to resumption etc needs to change 2. you avoid all of that spagetthi.

mediaItemIndex: Int,
isOriginal: Boolean
) {
sendCustomCommand(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

commented out much?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

qb doesn't own the current queue. Yes, a full queue was added into qb via addqueue, but all that data (with the exception of the title) remains untouched and isnt actually used anywhere. All the old data is overwritten anyways with the latest from the player when setmediaitems is called again, so there is no spaghetti required.

I see how that commitQueue nonsense is redundant, so I'll change that. I'll null out the info in addQueue to prevent that info from creeping in in the future, and also call super.

Now (fe511a1) it works like this for when setmediaitems is called: handleSetMediaItems -> addqueue adds skeleton queue to qb, -> commitqueue facilitates the active/inactive swap within qb (without its own setmediaitems) -> super.handleSetMediaItems

And then for user initiated queue swaps: commitqueue facilitates the active/inactive swap within qb (with realSetMediaItems) -> super.handleSetMediaItems. You cant call setmediaitems again

try {
mediaSession?.player?.setMediaItems(
items.mediaItems, items.startIndex, items.startPositionMs
val mq = endedWorkaroundPlayer?.queueBoard?.addQueue(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

see above, I think this part shouldnt need to change at all and qb should only manage inactive queue

}
}

SERVICE_QB_GET_QUEUE -> {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what is get for? can't we just have user call LOAD and then read it? or is there some UI where content of inactive is needed?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GET returns the queue with shuffle indexes for ui purposes, while LOAD is intended to facilitate the loading of the queue into the player. I don't believe these should be the same command.

}

/*
SERVICE_QB_ENQUEUE -> {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

commented out much?

throw IllegalStateException("shuffleFactory was found orphaned")
if (isForPlayback && items.mediaItems.isNotEmpty()) {
endedWorkaroundPlayer?.nextShuffleOrder = factory.toFactory()
// endedWorkaroundPlayer?.nextShuffleOrder = factory.toFactory()
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah...no


plr.exoPlayer.setShuffleOrder(seed.toFactory()(mq.startIndex, mq.getSize(), plr))
} else {
Log.d(TAG, "Seamless is not supported. Loading songs in directly")
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove logs btw

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd prefer not. I mean, I'm willing to guard it behind a flag, but I wish to keep some debug logs around

Log.d(TAG, "Seamless is not supported. Loading songs in directly")

if (plr.shuffleModeEnabled && mq.shuffleOrder == null)
Log.w(TAG, "Shuffle mode is enabled but no shuffle order is provided")
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what about not storing that boolean and just making null mean shuffle is off? avoids inconsistency altogether. (shuffle order doesn't need to be stored if shuffle is off because enabling will reshuffle anyway)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alright

setmediaitems stuff
let setmediaitems handle lastplayedmanager
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants